Skip to content

S00-00 General-Cloudflare-项目:jav-fs

[TOC]

项目开发文档大纲

这是一个非常棒的计划。我们将项目命名为 jav-fs (JAV Full Stack)

基于我们之前的深入讨论,我已经为你整理了一份系统性的开发文档大纲。为了让你能按部就班地完成开发,我将整个流程拆分为 6 个独立的里程碑(Steps)

你可以把这份大纲当作我们的总控文档。接下来,我们可以按照顺序,每次只专注完成一个步骤。

技术栈

  • 目标:构建一个高性能、低成本的 AV 作品与女优管理系统。
  • 核心技术栈
  • Runtime: Cloudflare Pages Functions (Serverless)
  • Framework: Hono (Backend) + Vue 3 (Frontend)
  • Database: Cloudflare D1 (SQLite)
  • Storage: Cloudflare R2 (Object Storage)
  • Language: TypeScript (全栈类型安全)

开发路线图

我们将按照以下 6 个步骤进行开发。每完成一步,你都可以向我发送指令(例如:“开始第一步”),我会生成该步骤详细的代码和操作指南。

  1. Step 1: 项目初始化与基础设施搭建

    目标:搭建 Monorepo 结构,配置 Cloudflare 环境。

    • 创建 Vite + Vue TS 项目。
    • 安装 Hono 及相关依赖。
    • 配置 wrangler.toml (绑定 D1 和 R2)。
    • 建立目录结构 (functions/ vs src/)。
    • 产出:一个可以跑通 "Hello World" 的全栈空壳项目。
  2. Step 2: 数据库架构设计

    目标:设计并应用数据库模型,解决 JSON 与 关联表的性能平衡。

    • 编写 schema.sql
    • 实现 idols 表(含虚拟列优化 bwh 搜索)。
    • 实现 works 表与 work_idols 多对多关联表。
    • 执行 D1 迁移命令。
    • 产出:完善的数据库结构,准备好录入数据。
  3. Step 3: 后端模块化开发

    目标:使用 Hono 的路由分组功能实现可维护的后端 API。

    • 定义全栈共享的 TypeScript 类型 (Shared Types)。
    • 实现模块化路由:functions/routes/idols.tsworks.ts
    • 实现核心查询逻辑(包括关联查询优化)。
    • 产出:功能完善的 REST API,可进行 CRUD 操作。
  4. Step 4: 对象存储与图片服务

    目标:实现图片的上传、存储与高性能读取。

    • 开发 upload.ts 路由,处理 Multipart 上传。
    • 开发图片代理/流式传输接口。
    • 处理 MIME 类型和 HTTP 缓存头。
    • 产出:图片上传接口,支持前端显示图片。
  5. Step 5: 前端 Vue 架构与页面开发

    目标:对接后端 API,构建用户界面。

    • 配置 Vite Proxy 解决本地开发跨域问题。
    • 封装 useApifetch 请求库。
    • 开发“女优列表页” (Grid 布局 + 分页)。
    • 开发“上传/新建页”。
    • 产出:完整的前端交互界面。
  6. Step 6: 安全、优化与部署

    目标:生产环境准备与上线。

    • 配置 CORS 策略。
    • 添加 API 鉴权中间件 (Bearer Token)。
    • 前端构建与 Wrangler 部署流程。
    • 产出:项目正式上线,可被公网访问。

项目初始化与基础设施搭建

目标:搭建 Monorepo 结构,配置 Cloudflare 环境。

  • 创建 Vite + Vue TS 项目。
  • 安装 Hono 及相关依赖。
  • 配置 wrangler.toml (绑定 D1 和 R2)。
  • 建立目录结构 (functions/ vs src/)。
  • 产出:一个可以跑通 "Hello World" 的全栈空壳项目。

创建项目

创建 Monorepo 结构的项目:

我们将使用 Vite 来创建项目,它将作为我们前端构建工具,同时也是我们项目的根目录。

打开终端,执行以下命令:

bash
# 1. 创建 Vue + TypeScript 项目
npm create vite@latest jav-fs -- --template vue-ts
# pnpm 写法(可以省略 `--`,因为 pnpm 会只能区分哪些参数属于它,哪些属于 vite ):
pnpm create vite@latest jav-fs --template vue-ts

# 2. 进入项目目录
cd jav-fs

# 3. 安装前端依赖
pnpm install

# 4. 安装后端核心依赖 (Hono)
pnpm install hono

# 5. 安装 Cloudflare 开发依赖 (Wrangler & Types)
pnpm install -D wrangler @cloudflare/workers-types

硬链接开发文档

为创建的开发文档创建一个硬链接,映射到 note 笔记项目中

bash
# windows
mklink /H "D:\Code\Note\doc\general\S00-00 General-Cloudflare-项目:jav-fs.md" "D:\Code\Project\Jav\jav-fs\项目:jav-fs.md"
  • 注意
    1. 该命令为 windows 命令,mac 系统命令为:ln <原文件名> <硬链接文件名>
    2. 文件名部分需要使用"<文件名>"包裹,因为文件名中有空格存在。

配置 wrangler.jsonc

配置 Cloudflare 环境 (wrangler.jsonc):

在项目根目录(jav-fs/)下创建一个名为 wrangler.jsonc 的文件。这是 Cloudflare Pages 的配置文件,用于定义数据库和存储桶的绑定。

注意:此时你还不需要在 Cloudflare 后台真的创建数据库,我们先配置好本地开发环境。

json
// jav-fs/wrangler.jsonc

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "jav-fs",
  "compatibility_date": "2026-02-13",
  "pages_build_output_dir": "./dist",
  "compatibility_flags": ["nodejs_compat"],
  "r2_buckets": [
    {
      "binding": "R2_JAV",
      "bucket_name": "jav"
    }
  ],
  "d1_databases": [
    {
      "binding": "DB_JAV",
      "database_name": "jav",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // 部署时才需要真实 ID
    },
    {
      "binding": "DB_STORAGE",
      "database_name": "storage",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" // 部署时才需要真实 ID
    }
  ]
}
toml
# jav-fs/wrangler.toml

name = "jav-fs"
pages_build_output_dir = "./dist"
compatibility_date = "2024-02-12"

# 绑定 D1 数据库 (本地开发时会自动创建 SQLite 文件)
[[d1_databases]]
binding = "DB"
database_name = "jav-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # 部署时才需要真实 ID

# 绑定 R2 存储桶
[[r2_buckets]]
binding = "R2"
bucket_name = "jav-assets"

规划目录结构

规划目录结构:

我们需要在根目录下创建一个 functions 文件夹。这是 Cloudflare Pages 的约定:任何放在 functions 文件夹下的代码都会被自动部署为后端 API

在终端执行:

bash
mkdir -p functions/api

此时你的目录结构应该是这样的:

text
jav-fs/
├── functions/       <-- 后端代码在这里
│   └── api/
├── src/             <-- 前端 Vue 代码在这里
├── public/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
└── wrangler.toml    <-- 配置文件

创建第一个 Hono 后端接口

创建第一个 Hono 后端接口:

我们先写一个 "Hello World" 接口来测试环境是否打通。

创建文件:functions/api/[[route]].ts

  • [[route]] 是 Cloudflare 的通配符语法,表示捕获 /api/* 下的所有请求。

    typescript
    // functions/api/[[route]].ts
    
    import { Hono } from 'hono'
    import { handle } from 'hono/cloudflare-pages'
    
    // 定义环境类型
    type Bindings = {
      DB: D1Database
      R2: R2Bucket
    }
    
    // 初始化 Hono,设置基础路径为 /api
    const app = new Hono<{ Bindings: Bindings }>().basePath('/api')
    
    // 测试路由
    app.get('/hello', (c) => {
      return c.json({
        message: 'Hello from Hono & Cloudflare!',
        timestamp: new Date().toISOString()
      })
    })
    
    // 导出处理函数
    export const onRequest = handle(app)

配置前后端代理

配置前后端联调 (Vite Proxy):

在本地开发时,Vue 运行在 http://localhost:5173,而 Cloudflare Wrangler (后端) 运行在 http://127.0.0.1:8788

为了让前端能请求到后端,我们需要配置 Vite 的代理。

修改 vite.config.ts

typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      // 当前端请求 /api/xxx 时,自动转发到 Wrangler 的端口 8788
      '/api': {
        target: 'http://127.0.0.1:8788',
        changeOrigin: true
      }
    }
  }
})

编写启动脚本

编写启动脚本 (package.json):

为了方便开发,我们在 package.json 中添加一个脚本,同时启动后端模拟环境。

修改 package.jsonscripts 部分:

json
"scripts": {
  "dev": "vite",
  "build": "vue-tsc && vite build",
  "preview": "vite preview",
  "backend": "wrangler pages dev --local --port 8787"
}

验证时刻

现在,我们来验证整个环境是否搭建成功。你需要打开两个终端窗口

终端 1 (启动后端):

bash
pnpm run backend
  • 你应该会看到 Wrangler 启动,并提示监听在 http://127.0.0.1:8788
  • 它会自动在本地 .wrangler/ 目录下模拟 D1 和 R2,不需要联网。

终端 2 (启动前端):

bash
pnpm run dev
  • Vite 会启动在 http://localhost:5173

测试联通性:

打开浏览器访问:http://localhost:5173/api/hello

如果你看到如下 JSON 返回,恭喜你!Step 1 完美完成!🎉

json
{
  "message": "Hello from Hono & Cloudflare!",
  "timestamp": "202X-XX-XXT..."
}

遇到问题了

  1. 报错 Command not found: wrangler:

    确保你运行的是 npm run backend或者前面加了npx

  2. 浏览器显示 404:

    检查 URL 是否是 /api/hello。确保两个终端都在运行。

  3. Hono 严格路由模式区分/api/hello/api/hello/

    先设置 basePath('/api'),然后定义 app.get('/hello/')

    这意味着 Hono 内部注册的完整路由是 /api(即 /api + /hello/,但尾部斜杠被规范化掉了)。

    关键点在于:Hono 默认是严格匹配路由的,/api/hello/api/hello/ 被视为两个不同的路径:

    请求 URLHono 匹配的路径结果
    /api/hello/api/hello → 匹配 get('/hello')✅ 200
    /api/hello//api/hello/ → 没有匹配的路由❌ 404

    解决方案

    在创建 Hono 实例时,将 strict 选项设为 false,这样 Hono 会将 /api/hello/api/hello/ 视为相同路径:

    diff
    -const app = new Hono<{ Bindings: Bindings }>().basePath('/api')
    +const app = new Hono<{ Bindings: Bindings }>({ strict: false }).basePath('/api')

    strict: false:会让 Hono 在路由匹配时忽略尾部斜杠的差异,这样/api/hello/api/hello/ 都能正确匹配到 get('/hello/') 路由。

数据库架构设计

目标:设计并应用数据库模型,解决 JSON 与 关联表的性能平衡。

  • 编写 schema.sql
  • 实现 idols 表(含虚拟列优化 bwh 搜索)。
  • 实现 works 表与 work_idols 多对多关联表。
  • 执行 D1 迁移命令。
  • 建立 TypeScript 类型定义
  • 产出:完善的数据库结构,准备好录入数据。

我们将实现 “读写分离” 的策略:

  1. 写(关联表):使用 work_idols 处理多对多关系,确保数据严谨,方便搜索。

  2. 读(快照):在 works 表中冗余存储 idols_snapshot (JSON),确保列表页加载飞快,无需 JOIN。

编写 schema.sql

在项目根目录下创建一个名为 schema.sql 的文件。

我们将应用之前讨论的 生成列 (Generated Columns) 技术来优化 JSON 字段的索引(例如女优的罩杯/胸围)。

sql
-- 1. AV 女优表: idols
CREATE TABLE IF NOT EXISTS idols (
  -- 核心字段
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL UNIQUE,
  aliases TEXT, -- JSON Object: {"jp": "みやした れな", "en": "Miyashita Rena", "more": "['别名1','别名2']"}
  avatar_url TEXT,

  -- 身体数据
  cup TEXT,
  height INTEGER, -- 单位: cm
  birthday TEXT, -- YYYY-MM-DD
  hobbies TEXT, -- JSON Array: ["爱好1", "爱好2"]
  desc TEXT, -- 简介
  social_links TEXT, -- JSON Array:[{"type": "twitter", "url": "https://twitter.com/xxx"}]
  bwh TEXT, -- JSON Object: {"bust": 90, "waist": 60, "hips": 90}
  -- [优化] 虚拟列
  bust_size INTEGER GENERATED ALWAYS AS (json_extract(bwh, '$.bust')) VIRTUAL,

  -- 作品数据
  work_codes TEXT, -- JSON Array:["ABP-123", "IPZZ-456"]

  -- 状态数据
  is_banned INTEGER DEFAULT 0, -- 0: false, 1: true
  is_favorite INTEGER DEFAULT 0, -- 0: false, 1: true
	status INTEGER DEFAULT 1, -- 当前状态(0:引退,1:活跃,2:休业)
	debut_date TEXT, -- 出道日期(YYYY-MM-DD)
	retirement_date TEXT, -- 引退日期(YYYY-MM-DD)

  -- 关联数据
  agencies TEXT, -- JSON Array: ["经纪公司1", "经纪公司2"]
  makers TEXT, -- JSON Array: ["厂商1", "厂商2"]

  -- 时间戳
  created_at INTEGER DEFAULT (unixepoch()),
  updated_at INTEGER DEFAULT (unixepoch())
);
-- 索引设计
CREATE UNIQUE INDEX IF NOT EXISTS idx_idols_name_unique ON idols(name);
CREATE INDEX IF NOT EXISTS idx_idols_cup ON idols(cup);
CREATE INDEX IF NOT EXISTS idx_idols_bust_size ON idols(bust_size);
-- 建议给出道日期加索引,方便查询“最新出道”
CREATE INDEX IF NOT EXISTS idx_idols_debut_date ON idols(debut_date);
sql
-- 2. AV 作品表: works
CREATE TABLE IF NOT EXISTS works (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  code TEXT NOT NULL, -- 番号,如:ABP-123
	code_prefix TEXT NOT NULL, -- 番号前缀,如:ABP
  full_title TEXT, -- 包含系列、标题、是否无码、是否有字幕等信息的完整标题

  -- 关联数据
  idols_snapshot TEXT, -- JSON Array: [{id, name}] (冗余存储用于快速显示)
  tags_snapshot TEXT, -- JSON Array: [{id, name}] (冗余存储)

  -- 标记位
  is_u INTEGER DEFAULT 0, -- 无码
  is_c INTEGER DEFAULT 0, -- 字幕
  is_ad INTEGER DEFAULT 0, -- 广告
  is_4k INTEGER DEFAULT 0, -- 4K

  -- 网盘视频数据
  one_file_id TEXT, -- 用于移动、改名、删除
  one_pick_code TEXT, -- 用于下载、看视频
  one_sha1 TEXT, -- 用于秒传、去重
  file_size TEXT, -- 单位:Byte
  duration INTEGER, -- 单位: 秒

  -- 图片数据
  img_preview_grid_url TEXT, -- 预览网格图
  img_cover_url TEXT, -- 封面图
  img_sample_urls TEXT, -- JSON Array:["gif1_url", "gif2_url"]


  -- 元数据
  release_date TEXT, -- YYYY-MM-DD
  series TEXT, -- 系列,如:初中出し解禁
  director TEXT, -- 导演
  maker TEXT, -- 厂商

  -- 自定义数据
  rating INTEGER DEFAULT 0, -- 作品评分:0-10,整数
	bookmarks TEXT, -- 视频书签,JSON Array:[{"time": 90, "tip": "书签信息", "img": "书签图片链接"}]

  -- 其他字段
  title TEXT NOT NULL,
  desc TEXT, -- 简介(手动添加)

  -- 时间戳
  created_at INTEGER DEFAULT (unixepoch()),
  updated_at INTEGER DEFAULT (unixepoch())
);
-- 索引设计
CREATE INDEX IF NOT EXISTS idx_works_code ON works(code);
CREATE INDEX IF NOT EXISTS idx_works_code_prefix ON works(code_prefix);
CREATE INDEX IF NOT EXISTS idx_works_release_date ON works(release_date);
CREATE INDEX IF NOT EXISTS idx_works_maker ON works(maker);
CREATE INDEX IF NOT EXISTS idx_works_is_u ON works(is_u);
CREATE INDEX IF NOT EXISTS idx_works_rating ON works(rating);
sql
-- 3. 标签表: tags
CREATE TABLE IF NOT EXISTS tags (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE,
    one_tag_id TEXT,

    -- 其他字段
    name_jp TEXT,
    created_at INTEGER DEFAULT (unixepoch()),
    updated_at INTEGER DEFAULT (unixepoch())
);
sql
-- 4. 关联表: work_idols (多对多)
CREATE TABLE IF NOT EXISTS work_idols (
    work_id INTEGER,
    idol_id INTEGER,
    PRIMARY KEY (work_id, idol_id),
    FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE ON UPDATE CASCADE,
    FOREIGN KEY (idol_id) REFERENCES idols(id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- 索引设计:优化反向查询(Idol -> Works)
CREATE INDEX IF NOT EXISTS idx_work_idols_reverse ON work_idols(idol_id, work_id);
sql
-- 5. 关联表: work_tags (多对多)
CREATE TABLE IF NOT EXISTS work_tags (
    work_id INTEGER,
    tag_id INTEGER,
    PRIMARY KEY (work_id, tag_id),
    FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE ON UPDATE CASCADE,
    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- 索引设计:优化反向查询(Tag -> Works)
CREATE INDEX IF NOT EXISTS idx_work_tags_reverse ON work_tags(tag_id, work_id);

创建本地数据库

Cloudflare D1 允许我们在本地完全模拟数据库环境。我们需要执行命令让上面的 SQL 生效。

打开终端,运行:

bash
# --local 表示只在本地 `.wrangler/` 目录下创建数据库文件,不影响线上
pnpm exec wrangler d1 execute jav --local --file=./schema.sql

预期输出:

你会看到一系列 ✅ Executed x commands,表示表结构创建成功。

⚠️ 注意:如果你看到报错 Database not found,请检查 wrangler.toml[[d1_databases]]database_name = "jav" 是否配置正确。

问题:SQLite 不支持 ON UPDATE 列定义语法

image-20260213162137023

解决方案

  1. 删除 ON UPDATE

    image-20260213162303879

  2. 为每个表添加触发器来自动更新 updated_at

    sql
    -- 例如为 idols 表:
    CREATE TRIGGER IF NOT EXISTS trg_idols_updated_at
      AFTER UPDATE ON idols
      FOR EACH ROW
      BEGIN
        UPDATE idols SET updated_at = unixepoch() WHERE id = OLD.id;
      END;

定义 TypeScript 类型

定义 TypeScript 类型 (functions/types.ts):

为了让后续写代码时有智能提示,我们需要把数据库结构翻译成 TS 接口。

functions/ 目录下创建一个 types.ts 文件(如果之前没有 functions 目录,请手动创建)。

typescript
// functions/types.ts
import { Hono } from 'hono'

// 1. 定义 D1 数据库绑定和 R2 存储桶
export type Bindings = {
  DB: D1Database
  R2: R2Bucket
  API_SECRET?: string // 用于鉴权
}

// 2. 通用的 Hono 应用类型
export type HonoApp = Hono<{ Bindings: Bindings }>

// 3. 数据库模型 (对应 SQL 表结构)

// 女优模型
export interface Idol {
  id: number
  name: string
  aliases: string[] // 存入 DB 时需 JSON.stringify
  avatar_url?: string
  cup?: string
  bwh?: {
    bust: number
    waist: number
    hips: number
  }
  bust_size?: number // 虚拟列
  height?: number
  birthday?: string
  hobbies?: string
  social_links?: string[]
  isFavorite: number // SQLite 没有 boolean,用 0/1
  created_at: number
}

// 作品模型
export interface Work {
  id: number
  code: string
  title: string
  // 快照字段
  idols_snapshot: { id: number; name: string; avatar?: string }[]
  tags_snapshot: string[]

  release_date?: string
  duration?: number
  isU: number
  isH: number
  isC: number
  img_cover_url?: string
  maker?: string
}

验证时刻

我们需要确认数据库真的建好了。我们可以写一个临时的测试脚本,或者直接用 Wrangler 的交互模式来查表。

在终端输入以下命令进入 D1 交互模式:

bash
pnpm exec wrangler d1 execute jav --local --command "SELECT name FROM sqlite_master WHERE type='table';"

预期输出:

你应该能看到以下表名列表:

text
┌────────────┐
│ name       │
├────────────┤
│ idols      │
│ works      │
│ work_idols │
│ d1_migrations ... │
└────────────┘

看到这些表名,说明我们的 Step 2: 数据库架构设计 已经圆满完成!你的本地环境现在已经拥有了一个专业的 AV 数据库结构。

后端模块化开发

目标:使用 Hono 的路由分组功能实现可维护的后端 API。

  • 定义全栈共享的 TypeScript 类型 (Shared Types)。
  • 实现模块化路由:functions/routes/idols.tsworks.ts
  • 实现核心查询逻辑(包括关联查询优化)。
  • 产出:功能完善的 REST API,可进行 CRUD 操作。

路由目录结构

我们需要在 functions 目录下创建一个 routes 文件夹,用于存放不同业务模块的代码。

在终端执行:

bash
mkdir -p functions/routes

现在的目录结构:

text
functions/
├── api/
│   └── [[route]].ts   <-- 主入口
├── routes/            <-- [新建] 业务逻辑
│   ├── idols.ts       <-- [新建] 女优模块
│   └── works.ts       <-- [新建] 作品模块
└── types.ts           <-- [已存在] 类型定义

idols 模块

编写女优模块 (functions/routes/idols.ts):

这是最复杂的模块,因为它涉及到 JSON 字段的序列化(存)和反序列化(取)。

创建 functions/routes/idols.ts,写入以下代码:

typescript
import { Hono } from 'hono'
import { Bindings } from '../types'

const app = new Hono<{ Bindings: Bindings }>()

// 工具函数:安全解析 JSON
const safeParse = (str: string | null, fallback: any = []) => {
  try {
    return str ? JSON.parse(str) : fallback
  } catch {
    return fallback
  }
}

// 1. 获取女优列表 (支持分页 & 搜索)
// GET /api/idols?page=1&search=Yua
app.get('/', async (c) => {
  const page = Number(c.req.query('page') || 1)
  const search = c.req.query('search')
  const limit = 20
  const offset = (page - 1) * limit

  let query = 'SELECT * FROM idols'
  const params: any[] = []

  // 搜索逻辑
  if (search) {
    query += ' WHERE name LIKE ? OR aliases LIKE ?' //【此处 aliases 有问题】
    params.push(`%${search}%`, `%${search}%`)
  }

  query += ' ORDER BY isFavorite DESC, id DESC LIMIT ? OFFSET ?'
  params.push(limit, offset)

  const { results } = await c.env.DB.prepare(query)
    .bind(...params)
    .all()

  // 数据处理:将 SQLite 的 TEXT/INT 转为前端好用的格式
  const data = results.map((row: any) => ({
    ...row,
    aliases: safeParse(row.aliases),
    bwh: safeParse(row.bwh, {}),
    social_links: safeParse(row.social_links),
    hobbies: safeParse(row.hobbies),
    // SQLite 存的是 0/1,转为 Boolean
    isFavorite: Boolean(row.isFavorite),
    // 计算年龄 (可选)
    age: row.birthday ? Math.floor((new Date().getTime() - new Date(row.birthday).getTime()) / 31557600000) : null
  }))

  return c.json({
    page,
    data,
    hasMore: data.length === limit
  })
})

// 2. 获取单个女优详情
// GET /api/idols/:id
app.get('/:id', async (c) => {
  const id = c.req.param('id')
  const idol = await c.env.DB.prepare('SELECT * FROM idols WHERE id = ?').bind(id).first()

  if (!idol) return c.json({ error: 'Not Found' }, 404)

  return c.json({
    ...idol,
    aliases: safeParse(idol.aliases as string),
    bwh: safeParse(idol.bwh as string, {}),
    social_links: safeParse(idol.social_links as string),
    isFavorite: Boolean(idol.isFavorite)
  })
})

// 3. 创建女优 (POST)
// POST /api/idols
app.post('/', async (c) => {
  const body = await c.req.json()

  // 简单校验
  if (!body.name) return c.json({ error: 'Name is required' }, 400)

  // 插入数据 (注意:数组/对象需要 stringify)
  const result = await c.env.DB.prepare(
    `
    INSERT INTO idols (name, aliases, cup, bwh, birthday, avatar_url, social_links, desc)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  `
  )
    .bind(
      body.name,
      JSON.stringify(body.aliases || []),
      body.cup || null,
      JSON.stringify(body.bwh || {}), // 例如 {bust: 90, waist: 60, hips: 90}
      body.birthday || null,
      body.avatar_url || null,
      JSON.stringify(body.social_links || []),
      body.desc || null
    )
    .run()

  return c.json({ success: true, id: result.meta.last_row_id })
})

// 4. 切换收藏状态 (PATCH)
// PATCH /api/idols/:id/favorite
app.patch('/:id/favorite', async (c) => {
  const id = c.req.param('id')
  const { isFavorite } = await c.req.json()

  await c.env.DB.prepare('UPDATE idols SET isFavorite = ? WHERE id = ?')
    .bind(isFavorite ? 1 : 0, id)
    .run()

  return c.json({ success: true })
})

export default app

works 模块

编写作品模块 (functions/routes/works.ts):

作品模块相对简单,主要是列表查询。

创建 functions/routes/works.ts

typescript
import { Hono } from 'hono'
import { Bindings } from '../types'

const app = new Hono<{ Bindings: Bindings }>()

const safeParse = (str: string | null, fallback: any = []) => {
  try {
    return str ? JSON.parse(str) : fallback
  } catch {
    return fallback
  }
}

// GET /api/works
app.get('/', async (c) => {
  const page = Number(c.req.query('page') || 1)
  const limit = 20
  const offset = (page - 1) * limit

  // 直接查询 works 表,不需要 JOIN,因为我们有 snapshots
  const { results } = await c.env.DB.prepare(
    `
    SELECT * FROM works 
    ORDER BY release_date DESC 
    LIMIT ? OFFSET ?
  `
  )
    .bind(limit, offset)
    .all()

  const data = results.map((row: any) => ({
    ...row,
    idols: safeParse(row.idols_snapshot), // 直接拿快照显示头像和名字
    tags: safeParse(row.tags_snapshot),
    isU: Boolean(row.isU),
    isH: Boolean(row.isH),
    isC: Boolean(row.isC)
  }))

  return c.json({ data, page })
})

// GET /api/works/:code (根据番号查询)
app.get('/:code', async (c) => {
  const code = c.req.param('code')
  const work = await c.env.DB.prepare('SELECT * FROM works WHERE code = ?').bind(code).first()

  if (!work) return c.json({ error: 'Work not found' }, 404)

  return c.json({
    ...work,
    idols: safeParse(work.idols_snapshot as string),
    tags: safeParse(work.tags_snapshot as string),
    isU: Boolean(work.isU),
    isH: Boolean(work.isH),
    isC: Boolean(work.isC)
  })
})

export default app

[[route]] 入口

组装主入口 (functions/api/[[route]].ts):

最后,我们需要修改主入口文件,把上面写好的两个模块挂载上去。

修改 functions/api/[[route]].ts

ts
import { Hono } from 'hono'
import { handle } from 'hono/cloudflare-pages'
import { cors } from 'hono/cors' // 记得安装依赖: npm install hono
import { Bindings } from '../types'

// 1. 引入子路由
import idolsApp from '../routes/idols'
import worksApp from '../routes/works'

const app = new Hono<{ Bindings: Bindings }>().basePath('/api')

// 全局中间件
app.use('/*', cors()) // 允许跨域,方便前端开发

// 2. 挂载子路由
// 访问 /api/idols/* -> idolsApp
// 访问 /api/works/* -> worksApp
app.route('/idols', idolsApp)
app.route('/works', worksApp)

// 全局错误处理
app.onError((err, c) => {
  console.error('API Error:', err)
  return c.json({ error: err.message }, 500)
})

export const onRequest = handle(app)

验证时刻

现在,你的后端已经具备了真实的业务能力。我们需要验证 API 是否工作正常。

  1. 启动后端 (如果之前没关,它会自动热更新;如果关了,重新运行):

    bash
    npm run backend
  2. 测试创建女优 (POST)

    打开一个新的终端窗口,使用 curl 发送一个 POST 请求(或者使用 Postman):

    bash
    curl -X POST http://127.0.0.1:8788/api/idols \
    -H "Content-Type: application/json" \
    -d '{
     "name": "三上悠亚",
     "aliases": ["Yua Mikami", "鬼头桃菜"],
     "cup": "F",
     "bwh": {"bust": 88, "waist": 58, "hips": 89},
     "birthday": "1993-08-16"
    }'

预期返回{"success":true,"id":1}

  1. 测试查询列表 (GET)

    bash
    curl http://127.0.0.1:8788/api/idols

预期返回:你应该能看到刚刚创建的数据,且 aliases 已经被解析为数组,bwh 被解析为对象。

如果这两个接口都能通,说明我们的 Step 3: 后端模块化开发 已经圆满成功!我们现在拥有了一个可扩展、类型安全、且功能完备的后端核心。

对象存储与图片服务

目标:实现图片的上传、存储与高性能读取。

  • 开发 upload.ts 路由,处理 Multipart 上传。
  • 开发图片代理/流式传输接口。
  • 处理 MIME 类型和 HTTP 缓存头。
  • 产出:图片上传接口,支持前端显示图片。

这一步的目标是打通 Cloudflare R2 对象存储。我们将实现一个“文件上传接口”和一个“图片读取接口”。

这样,你在创建女优或作品时,就可以先上传图片 -> 拿到 URL -> 再把 URL 存入数据库

创建图片上传模块

创建上传模块 (functions/routes/upload.ts):

我们需要处理前端发来的 multipart/form-data(表单文件上传)。Hono 提供了很好的解析支持。

新建文件 functions/routes/upload.ts

ts
import { Hono } from 'hono'
import { Bindings } from '../types'

const app = new Hono<{ Bindings: Bindings }>()

/** 上传图片 (PUT /api/upload) */
// 前端使用 FormData 上传,字段名为 'file'
app.put('/', async (c) => {
  // 1. 解析表单数据
  const body = await c.req.parseBody()
  const file = body['file'] // 获取名为 file 的字段

  // 2. 检查是否为文件
  if (!file || !(file instanceof File)) {
    return c.json({ error: 'No file uploaded' }, 400)
  }

  // 3. 生成唯一文件名 (时间戳 + 随机数 + 后缀)
  // 也可以直接用 file.name,但为了防止重名覆盖,建议重命名
  const suffix = file.name.split('.').pop() || 'jpg'
  const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${suffix}`

  // 4. 写入 R2 存储桶
  await c.env.R2.put(filename, await file.arrayBuffer(), {
    httpMetadata: {
      contentType: file.type // 保存文件的 MIME 类型 (如 image/png)
    }
  })

  // 5. 返回相对路径,前端拼接显示
  return c.json({
    success: true,
    url: `/api/images/${filename}`, // 这里的路径对应下面的读取接口
    filename: filename
  })
})

export default app

修改 functions/api/[[route]].ts,挂载上传路由

ts
// 1. 引入 upload 模块
import uploadApp from '../routes/upload' // <--- 新增引入

// ... 全局中间件 ...

// 2. 挂载上传路由
app.route('/upload', uploadApp) // <--- 新增挂载

进阶:图片批量上传

这是一个非常实用的需求。在 jav-fs 项目中,实现批量上传主要涉及到两个改动:

  1. 后端 (Hono): 需要能够接收并处理 FormData 中的文件数组。

  2. 前端 (Vue): 需要使用 <input type="file" multiple> 并正确封装请求。

我们需要修改 Step 4 中创建的 upload.ts

修改后端接口

修改后端接口 (functions/routes/upload.ts):

我们将接口逻辑升级:既支持单文件,也支持多文件

核心逻辑变动

  • Hono 的 c.req.parseBody({ all: true }):我们需要加上 { all: true } 选项,这样当上传多个文件时,它会返回一个数组,而不是只返回最后一个文件。
  • 使用 Promise.all 并行上传到 R2,提高速度。

请更新 functions/routes/upload.ts 的代码:

ts
import { Hono } from 'hono'
import { Bindings } from '../types'

const app = new Hono<{ Bindings: Bindings }>()

// 修改为 POST 方法更符合语义 (批量创建资源)
app.post('/', async (c) => {
  // 1. 解析 FormData,启用 { all: true } 以支持多文件数组
  const body = await c.req.parseBody({ all: true })

  // 2. 获取文件字段 (假设前端传的字段名为 'files')
  // Hono 处理 parseBody 时,如果是单文件返回 File,多文件返回 File[]
  // 我们统一转为数组处理
  let files = body['files']

  if (!files) {
    return c.json({ error: 'No files uploaded' }, 400)
  }

  // 归一化:强制转为数组
  if (!Array.isArray(files)) {
    files = [files]
  }

  // 过滤掉非文件类型 (比如可能混入的文本字段)
  const validFiles = (files as File[]).filter((f) => f instanceof File)

  if (validFiles.length === 0) {
    return c.json({ error: 'Invalid files' }, 400)
  }

  // 3. 并行上传到 R2
  const uploadPromises = validFiles.map(async (file) => {
    const suffix = file.name.split('.').pop() || 'jpg'
    // 生成唯一文件名
    const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${suffix}`

    await c.env.R2.put(filename, await file.arrayBuffer(), {
      httpMetadata: { contentType: file.type }
    })

    return {
      originalName: file.name,
      filename: filename,
      url: `/api/images/${filename}`
    }
  })

  // 等待所有上传完成
  const results = await Promise.all(uploadPromises)

  return c.json({
    success: true,
    count: results.length,
    files: results
  })
})

export default app

注意:记得在 functions/api/[[route]].ts 中确认挂载路径。如果你之前写的是 app.put,现在要改用 POST 请求这个接口。

前端如何调用 (预览)

前端如何调用 (预览):

虽然我们还没到 Step 5,但我先给你看下前端代码怎么写,方便你理解原理。

在 Vue 组件中:

typescript
// 假设这是 handleUpload 方法
const uploadImages = async (event: Event) => {
  const input = event.target as HTMLInputElement
  if (!input.files?.length) return

  const formData = new FormData()

  // 关键点:遍历所有选中的文件,append 到同一个字段名 'files' 下
  for (let i = 0; i < input.files.length; i++) {
    // 注意:这里的 key 必须和后端接收的 key ('files') 一致
    formData.append('files', input.files[i])
  }

  const res = await fetch('/api/upload', {
    method: 'POST', // 改为 POST
    body: formData // 浏览器会自动设置 Content-Type 为 multipart/form-data
  })

  const data = await res.json()
  console.log('上传成功:', data.files)
  // data.files 是一个包含所有图片 URL 的数组
}

HTML 部分:

html
<input type="file" multiple @change="uploadImages" />

使用 Curl 测试批量上传

使用 Curl 测试批量上传:

你可以用 curl 模拟一次发两张图:

  1. 准备两个文件 a.jpgb.jpg

  2. 运行命令:

    bash
    # 注意 -F "files=@..." 写了两次,这相当于追加同名字段
    curl -X POST \
    -F "files=@a.jpg" \
    -F "files=@b.jpg" \
    http://127.0.0.1:8788/api/upload

预期返回结果

json
{
"success": true,
"count": 2,
"files": [
 { "originalName": "a.jpg", "url": "/api/images/xxx-1.jpg", ... },
 { "originalName": "b.jpg", "url": "/api/images/xxx-2.jpg", ... }
]
}

这样你就完美实现了批量上传功能!后端一次处理,并行写入 R2,效率很高。

创建图片读取接口

创建图片读取接口:

虽然 R2 可以配置自定义域名直接访问(性能最好),但为了开发方便(不需要买域名),我们可以直接用 Cloudflare Functions 做一个简单的代理。

我们需要在主入口文件 functions/api/[[route]].ts 中增加一个读取路由。

修改 functions/api/[[route]].ts,在 app.route 下方添加:

ts
// ... 前面的 imports 和 app 初始化 ...

// [新增] 图片读取代理接口
// GET /api/images/:filename
app.get('/images/:filename', async (c) => {
  const filename = c.req.param('filename')

  // 1. 从 R2 获取对象
  const object = await c.env.R2.get(filename)

  if (!object) {
    return c.text('Image Not Found', 404)
  }

  // 2. 设置响应头
  const headers = new Headers()
  object.writeHttpMetadata(headers)
  // ETag 用于浏览器缓存
  headers.set('etag', object.httpEtag)
  // 设置缓存策略 (例如缓存 1 年)
  headers.set('Cache-Control', 'public, max-age=31536000')

  // 3. 返回图片
  return new Response(object.body, {
    headers
  })
})

// ... 错误处理和导出 ...

为什么这样做:

  1. PUT /api/upload: 负责接收文件流,写入 R2。我们使用了 parseBody() 来自动处理 FormData,这样前端代码写起来最自然。

  2. GET /api/images/:filename: 这是一个“代理”。浏览器请求 API -> API 去 R2 取货 -> API 返回给浏览器。

    • 优点:无需配置 DNS,开箱即用,支持鉴权(如果需要)。
    • 缺点:消耗 Functions 调用次数(但在免费额度内基本够用)。

验证时刻

我们需要验证 R2 是否工作正常。确保你的后端终端 (npm run backend) 正在运行。

准备一张测试图片:

找一张小图片,命名为 test.jpg,放在项目根目录下。

使用 Curl 测试上传:

打开新终端,在项目根目录执行:

bash
# 使用 -F 模拟表单文件上传
curl -X PUT -F "file=@test.jpg" http://127.0.0.1:8788/api/upload

预期输出:

json
{
  "success": true,
  "url": "/api/images/1707746123456-x7z9.jpg",
  "filename": "1707746123456-x7z9.jpg"
}

记下这个 url

验证图片读取:

打开浏览器,访问:

http://127.0.0.1:8788 + 刚刚返回的 url

(例如:http://127.0.0.1:8788/api/images/1707746123456-x7z9.jpg

如果你能在浏览器里看到这张图片,恭喜!🎉

前端 Vue 架构与页面开发

目标:对接后端 API,构建用户界面。

  • 配置 Vite Proxy 解决本地开发跨域问题。
  • 封装 useApifetch 请求库。
  • 开发“女优列表页” (Grid 布局 + 分页)。
  • 开发“上传/新建页”。
  • 产出:完整的前端交互界面。

太棒了,进入 Step 5: 前端 Vue 架构与页面开发!🎨

这一步我们将离开终端和黑底白字的 API,开始构建用户真正看得见、摸得着的界面。我们将实现一个现代化的 Single Page Application (SPA)。

目标

  1. 搭建 Vue Router 路由系统。

  2. 封装 API 请求层。

  3. 实现 “女优列表页” (瀑布流展示)。

  4. 实现 “新建女优页” (包含我们刚刚讨论的批量图片上传功能)。

首先,确保你位于项目根目录 jav-fs/

安装 Vue Router

安装 Vue Router:

我们需要路由来管理“列表页”和“新建页”之间的跳转。

bash
npm install vue-router@4

封装 API 请求层

封装 API 请求层 (src/api/index.ts):

不要在每个组件里写 fetch('/api/...'),这很难维护。我们要统一封装。

创建 src/api 目录和 index.ts 文件:

bash
mkdir -p src/api
touch src/api/index.ts

写入以下代码:

typescript
// src/api/index.ts
import type { Idol } from '../../functions/types' // 复用后端的类型定义!

const API_PREFIX = '/api'

// 通用 Fetch 包装器
async function request<T>(url: string, options?: RequestInit): Promise<T> {
  const res = await fetch(`${API_PREFIX}${url}`, options)
  if (!res.ok) {
    const err = await res.json().catch(() => ({}))
    throw new Error(err.error || 'Network response was not ok')
  }
  return res.json()
}

export const api = {
  // 1. 获取女优列表
  getIdols: (page = 1, search = '') =>
    request<{ data: Idol[]; page: number; hasMore: boolean }>(`/idols?page=${page}&search=${search}`),

  // 2. 创建女优
  createIdol: (data: Partial<Idol>) =>
    request<{ success: boolean; id: number }>('/idols', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    }),

  // 3. 上传图片 (支持批量)
  uploadImages: async (files: FileList | File[]) => {
    const formData = new FormData()
    // 强制转为数组并遍历
    Array.from(files).forEach((file) => formData.append('files', file))

    // 注意:fetch 会自动设置 Content-Type 为 multipart/form-data,不要手动设置
    return request<{ success: true; files: { url: string }[] }>('/upload', {
      method: 'POST',
      body: formData
    })
  }
}

配置路由

配置路由 (src/router/index.ts):

创建 src/router/index.ts

typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import IdolList from '../views/IdolList.vue'
import IdolCreate from '../views/IdolCreate.vue'

const routes = [
  { path: '/', redirect: '/idols' },
  { path: '/idols', component: IdolList },
  { path: '/idols/new', component: IdolCreate }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

同时,修改 src/main.ts 启用路由:

typescript
// src/main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { router } from './router'

createApp(App).use(router).mount('#app')

开发页面组件

开发页面组件:

我们需要创建 src/views 文件夹。

1. 女优列表页 (src/views/IdolList.vue)

这是一个简单的网格布局,展示头像和名字。

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import type { Idol } from '../../functions/types'

const idols = ref<Idol[]>([])
const loading = ref(false)

const loadData = async () => {
  loading.value = true
  try {
    const res = await api.getIdols()
    idols.value = res.data
  } finally {
    loading.value = false
  }
}

onMounted(loadData)
</script>

<template>
  <div class="page">
    <header>
      <h1>女优列表</h1>
      <router-link to="/idols/new" class="btn">➕ 新建女优</router-link>
    </header>

    <div v-if="loading">加载中...</div>

    <div class="grid">
      <div v-for="idol in idols" :key="idol.id" class="card">
        <div class="avatar">
          <img v-if="idol.avatar_url" :src="idol.avatar_url" loading="lazy" />
          <div v-else class="placeholder">无图</div>
        </div>
        <h3>{{ idol.name }}</h3>
        <p class="meta">{{ idol.cup }}杯 | {{ idol.age ? idol.age + '岁' : '未知' }}</p>
      </div>
    </div>
  </div>
</template>

<style scoped>
.page {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}
header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 20px;
}
.card {
  border: 1px solid #eee;
  border-radius: 8px;
  overflow: hidden;
  padding-bottom: 10px;
  text-align: center;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.avatar {
  height: 240px;
  background: #f9f9f9;
}
.avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.placeholder {
  line-height: 240px;
  color: #ccc;
}
.btn {
  background: #3b82f6;
  color: white;
  padding: 8px 16px;
  border-radius: 4px;
  text-decoration: none;
}
.meta {
  color: #666;
  font-size: 0.9em;
}
</style>

2. 新建女优页 (src/views/IdolCreate.vue)

这里集成了图片上传逻辑。

vue
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api'

const router = useRouter()
const form = reactive({
  name: '',
  cup: '',
  aliases: '', // 输入时用逗号分隔
  avatar_url: ''
})
const uploading = ref(false)

// 处理图片上传
const handleFileChange = async (e: Event) => {
  const files = (e.target as HTMLInputElement).files
  if (!files?.length) return

  uploading.value = true
  try {
    // 调用我们写的批量上传接口,但这里只取第一张作为头像
    const res = await api.uploadImages(files)
    if (res.files.length > 0) {
      form.avatar_url = res.files[0].url
    }
  } catch (err) {
    alert('上传失败')
  } finally {
    uploading.value = false
  }
}

// 提交表单
const handleSubmit = async () => {
  if (!form.name) return alert('请填写名字')

  await api.createIdol({
    name: form.name,
    cup: form.cup,
    // 将字符串 "A, B" 转为数组 ["A", "B"]
    aliases: form.aliases
      .split(/[,,]/)
      .map((s) => s.trim())
      .filter(Boolean),
    avatar_url: form.avatar_url
  })

  router.push('/idols') // 跳转回列表
}
</script>

<template>
  <div class="page">
    <h1>新建女优</h1>
    <form @submit.prevent="handleSubmit" class="form">
      <div class="form-group">
        <label>头像</label>
        <div class="upload-preview" v-if="form.avatar_url">
          <img :src="form.avatar_url" />
        </div>
        <input type="file" @change="handleFileChange" accept="image/*" />
        <span v-if="uploading">上传中...</span>
      </div>

      <div class="form-group">
        <label>姓名</label>
        <input v-model="form.name" type="text" placeholder="例如: 三上悠亚" />
      </div>

      <div class="form-group">
        <label>别名 (用逗号分隔)</label>
        <input v-model="form.aliases" type="text" placeholder="Yua Mikami, 鬼头" />
      </div>

      <div class="form-group">
        <label>罩杯</label>
        <select v-model="form.cup">
          <option value="">请选择</option>
          <option v-for="c in ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']" :key="c" :value="c">
            {{ c }}
          </option>
        </select>
      </div>

      <button type="submit" class="btn-primary">保存数据</button>
    </form>
  </div>
</template>

<style scoped>
.page {
  max-width: 600px;
  margin: 40px auto;
  padding: 20px;
}
.form-group {
  margin-bottom: 20px;
}
.form-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}
.form-group input,
select {
  width: 100%;
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.upload-preview img {
  height: 150px;
  border-radius: 4px;
  margin-bottom: 10px;
}
.btn-primary {
  background: #10b981;
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}
</style>

修改 App 入口

修改 App 入口 (src/App.vue):

最后,清理 App.vue,只保留路由出口。

vue
<script setup lang="ts">
// 什么都不需要写
</script>

<template>
  <router-view></router-view>
</template>

<style>
/* 全局重置样式 */
body {
  margin: 0;
  font-family: sans-serif;
  background: #f5f5f5;
}
* {
  box-sizing: border-box;
}
</style>

验证时刻 (全栈联调)

✅ 验证时刻 (全栈联调):

这一步最激动人心,因为我们要把所有东西连起来了!

  1. 启动后端 (如果还没启动):

    bash
    npm run backend
  2. 启动前端 (新建终端窗口):

    bash
    npm run dev
  3. 打开浏览器:

    访问 http://localhost:5173

    • 你应该能看到: 一个空的列表页(如果你之前清空了数据)。
    • 点击 "➕ 新建女优": 跳转到表单页。
    • 上传一张图片: 你会看到上传进度,然后图片预览显示出来(图片已经存入 R2)。
    • 填写名字并保存: 页面自动跳转回列表页,并且显示出你刚刚创建的卡片!

🎉 恭喜!你已经完成了一个真正的全栈应用 (JAV-FS)。

  • 前端:Vue 3 + Vue Router
  • API:Hono (RESTful)
  • 数据库:D1 (SQLite)
  • 存储:R2 (Images)
  • 环境:Cloudflare Pages

安全、优化与部署

目标:生产环境准备与上线。

  • 配置 CORS 策略。
  • 添加 API 鉴权中间件 (Bearer Token)。
  • 前端构建与 Wrangler 部署流程。
  • 产出:项目正式上线,可被公网访问。

太棒了!我们终于来到了最激动人心的最后一步!🚀

Step 6: 安全、优化与上线部署

你在本地已经拥有了一个完美运行的全栈应用。但如果要把它放到公网上,任何人都能随便调用你的 API 删改数据可不行。

这一步的目标是:

  1. 加把锁:为增删改接口(POST/PUT/PATCH)添加鉴权机制。

  2. 建线上资源:在 Cloudflare 真实环境中创建 D1 和 R2。

  3. 一键发布:将前后端打包,部署到全球边缘网络。

后端添加 API 鉴权

后端添加 API 鉴权 (Security):

我们需要在主路由拦截非法的写入请求。

修改 functions/api/[[route]].ts,在挂载子路由之前,添加一个简单的 Bearer Token 鉴权中间件:

typescript
// functions/api/[[route]].ts
import { Hono } from 'hono'
import { handle } from 'hono/cloudflare-pages'
import { cors } from 'hono/cors'
import { Bindings } from '../types'

import idolsApp from '../routes/idols'
import worksApp from '../routes/works'
import uploadApp from '../routes/upload'

const app = new Hono<{ Bindings: Bindings }>().basePath('/api')

// 1. 允许跨域
app.use('/*', cors())

// 2. [新增] 简易鉴权中间件
app.use('/*', async (c, next) => {
  // 允许所有 GET 请求 (公开读取)
  if (c.req.method === 'GET') {
    return await next()
  }

  // 拦截 POST, PUT, PATCH, DELETE 等写入请求
  const authHeader = c.req.header('Authorization')
  // 获取环境变量中的密钥,本地开发时默认用 'dev-secret'
  const secret = c.env.API_SECRET || 'dev-secret'

  if (authHeader !== `Bearer ${secret}`) {
    return c.json({ error: 'Unauthorized: 密钥无效' }, 401)
  }

  await next()
})

// 3. 挂载子路由
app.route('/idols', idolsApp)
app.route('/works', worksApp)
app.route('/upload', uploadApp)

// 4. 图片读取代理
app.get('/images/:filename', async (c) => {
  // ... 之前的图片代理代码保持不变 ...
  const filename = c.req.param('filename')
  const object = await c.env.R2.get(filename)
  if (!object) return c.text('Not Found', 404)
  const headers = new Headers()
  object.writeHttpMetadata(headers)
  headers.set('etag', object.httpEtag)
  return new Response(object.body, { headers })
})

export const onRequest = handle(app)

前端携带 Token 发起请求

前端携带 Token 发起请求:

既然加入了鉴权,前端 POST 数据时必须带上钥匙。

修改 src/api/index.ts 中的 request 函数:

typescript
// src/api/index.ts
// ... 之前的导入保持不变 ...

// 模拟获取 Token,实际项目中可以做个登录页存到 localStorage
const getAuthToken = () => {
  return localStorage.getItem('jav_token') || 'dev-secret'
}

async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
  const headers = new Headers(options.headers || {})

  // 注入 Authorization 头
  headers.set('Authorization', `Bearer ${getAuthToken()}`)

  const res = await fetch(`${API_PREFIX}${url}`, {
    ...options,
    headers
  })

  if (!res.ok) {
    const err = await res.json().catch(() => ({}))
    throw new Error(err.error || 'Network response was not ok')
  }
  return res.json()
}

// ... 下面的 api 对象保持不变 ...

(提示:你可以在浏览器的控制台执行 localStorage.setItem('jav_token', '你的线上真实密码') 来模拟登录。)

在 Cloudflare 创建真实资源

在 Cloudflare 创建真实资源:

之前的开发都在本地模拟(.wrangler 文件夹),现在我们需要在 Cloudflare 云端创建真实的数据库和存储桶。

确保你已经通过 npx wrangler login 登录了你的 Cloudflare 账号。

1. 创建云端 D1 数据库

bash
npx wrangler d1 create jav-db

执行后,终端会打印出一段配置信息,类似这样:

toml
[[d1_databases]]
binding = "DB"
database_name = "jav-db"
database_id = "xxxxxx-xxxx-xxxx-xxxx-xxxxxxxx" # 复制这个真实的 ID

重要:将这段真实的 database_id 替换到你项目根目录的 wrangler.toml 文件中!

2. 初始化云端数据库表结构

我们需要把本地的表结构推送到刚刚创建的云端数据库。

bash
npx wrangler d1 execute jav-db --remote --file=./schema.sql

3. 创建云端 R2 存储桶

bash
npx wrangler r2 bucket create jav-assets

一键发布上线

一键发布上线 (Deployment):

万里长征最后一步!我们将 Vue 前端打包为静态文件,连同后端 API 一起部署到 Cloudflare Pages。

1. 打包前端代码

bash
npm run build

(这会生成一个 dist 文件夹,里面是压缩后的 HTML/JS/CSS)

2. 部署到 Cloudflare Pages

bash
npx wrangler pages deploy dist

部署过程中,CLI 会问你几个问题:

  • Create a new project?: 选择 Create a new project (如果是第一次部署)。
  • Enter production branch name: 直接按回车默认 mainmaster 即可。

稍等片刻,Wrangler 会给你返回一个线上的 URL,类似:

https://jav-fs-xxx.pages.dev

设置线上环境变量

设置线上环境变量 (API_SECRET):

你的项目已经上线了!但为了安全,我们需要把云端的 API_SECRET 改成一个强密码,而不是 dev-secret

  1. 登录 Cloudflare 控制台。

  2. 进入 Workers & Pages -> 找到你的项目 jav-fs

  3. 去到 Settings (设置) -> Environment variables (环境变量)

  4. 添加一个变量:

    • Variable name: API_SECRET
    • Value: 你自己设置的复杂密码 (保存好这个密码,然后在你本地浏览器的 localStorage 里设置 jav_token 为这个密码)。
  5. 保存后,重新执行一次部署命令 (npx wrangler pages deploy dist) 使环境变量生效。

大功告成!

🎉 大功告成!:

恭喜你!你已经从零开始,完整构建并上线了一个现代化的全栈系统。回顾一下你学到了什么:

  • 数据库设计:D1 (SQLite) 的 JSON 虚拟列优化与多对多关联设计。
  • 后端开发:使用 Hono 框架构建轻量级、模块化的 RESTful API。
  • 对象存储:结合 R2 处理 multipart/form-data 实现图片上传。
  • 前端开发:使用 Vue 3 + Composition API + Router 构建 SPA。
  • 全栈部署:利用 Cloudflare 边缘网络,实现零运维、低成本的高性能部署。

这个项目 (jav-fs) 的架构非常扎实,完全可以作为你未来开发博客、商城、管理后台的通用模板。

如果你在部署过程中遇到任何报错,或者未来想要增加新功能(比如接入爬虫自动抓取数据),随时可以来找我讨论!需要我帮你整理一份完整的 README 吗?